feat(api): accept user and team locators on grants - #1192
Conversation
Create, get, and list name the bound person as user or team instead of principal_type / principal_id. Create resolves user_id, identifier, team_id, or team name in the platform project; storage and events stay id-based. Expand inlines extras onto the same user / team ref. Co-authored-by: Silvan <adlerhurst@users.noreply.github.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
2 Skipped Deployments
|
🦋 Changeset detectedLatest commit: cc9c09b The changes in this PR will be included in the next version bump. This PR includes changesets to release 20 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
🟡 Changes recommended
QueryGrants filtering for user_id/team_id regresses the documented contains operation by returning NotImplemented instead of performing a substring match.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR delivers a breaking (alpha) update to the Grants HTTP API by replacing (principal_type, principal_id) with explicit user / team locators, adding locator-based create semantics (user identifier / team name), and changing expand: ["principal"] to inline expanded fields onto the same user / team objects. It also updates console admin-grant creation and all relevant tests/docs/specs accordingly.
Changes:
- Replace
principal_type/principal_idwithuser(user_id/identifier) orteam(team_id/name) across create/get/list/query. - Implement locator resolution in the Grant service (user by designated identifier; team by case-insensitive name) and add GET-by-id
expand=principalsupport withuser.read+team.readgating. - Update console admin UI + tests and refresh OpenAPI + generated API artifacts + ADRs + changeset.
File summaries
| File | Description |
|---|---|
| internal/service/grant.go | Implements locator resolution, new validation, hydration toggled by expand, and updated query filtering fields. |
| internal/service/grant_test.go | Updates existing grant tests and adds service-level locator resolution coverage. |
| internal/api/grant.go | Maps new request shapes to service inputs; enforces expand gating for GET/query; maps response into new user/team shapes. |
| internal/api/grant_internal_test.go | Adds unit tests for create request mapping and updated grant response behavior. |
| internal/api/integration_test/grant_test.go | Updates integration tests for new wire format, expand behavior, and locator creates. |
| apps/console/src/routes/_authed/settings/admins.tsx | Switches admin listing/labeling and “already admin” detection to user/team objects. |
| apps/console/src/routes/_authed/settings/admins.spec.tsx | Updates MSW fixtures and assertions to the new grants wire format and expand semantics. |
| apps/console/src/components/add-admin-dialog.tsx | Updates grant creation payload to { user: { user_id }, relation }. |
| docs/adrs/README.md | Extends ADR 059 index description to mention the grant expansion exception. |
| docs/adrs/059-expanding-embedded-objects.md | Documents the grant-specific expand exception (inline extras on refs, degraded ref behavior). |
| docs/adrs/054-customer-collaboration-grants.md | Updates grant principal naming to include locator-based addressing. |
| api/openapi/endpoints/grants/user-locator.yaml | Adds UserLocator schema (user_id XOR identifier). |
| api/openapi/endpoints/grants/team-locator.yaml | Adds TeamLocator schema (team_id XOR name). |
| api/openapi/endpoints/grants/query/query-grants-request.yaml | Updates expand description to match inline-extras behavior. |
| api/openapi/endpoints/grants/query/methods.yaml | Updates query endpoint docs for inline expand semantics + gating. |
| api/openapi/endpoints/grants/query/grant-filter-field.yaml | Replaces principal filters with user_id / team_id. |
| api/openapi/endpoints/grants/query/grant-expand.yaml | Redefines principal expand as “inline extras onto user/team ref”. |
| api/openapi/endpoints/grants/methods.yaml | Updates create endpoint docs for locator inputs and ref-only 201 response. |
| api/openapi/endpoints/grants/grant.yaml | Replaces grant schema to discriminate on presence of user vs team and removes sibling principal. |
| api/openapi/endpoints/grants/grant-user.yaml | Introduces GrantUser (user-ref + inline expand extras). |
| api/openapi/endpoints/grants/grant-team.yaml | Introduces GrantTeam (team-ref + inline expand extras). |
| api/openapi/endpoints/grants/grant-expanded-principal.yaml | Removes the previous expanded-principal union schema. |
| api/openapi/endpoints/grants/create-grant-request.yaml | Updates CreateGrantRequest to accept user or team locator objects. |
| api/openapi/endpoints/grants/by_id/methods.yaml | Adds GET-by-id expand query param contract. |
| api/openapi/endpoints/grants/by_id/getGrant-error-response.yaml | Adds user.permission_denied / team.permission_denied to GET error catalog. |
| api/generated/oas_validators_gen.go | Regenerates validators for new request/response shapes. |
| api/generated/oas_unimplemented_gen.go | Regenerates unimplemented handler docs for updated grants endpoints. |
| api/generated/oas_server_gen.go | Regenerates handler interface and endpoint docs for updated grants endpoints. |
| api/generated/oas_schemas_gen.go | Regenerates Go types for new locator and grant schemas. |
| api/generated/oas_parameters_gen.go | Adds expand query decoding for GetGrant. |
| api/generated/oas_json_gen.go | Regenerates JSON encode/decode for new types and removed principal union. |
| api/generated/oas_handlers_gen.go | Regenerates server request handlers (expand param plumbing). |
| api/generated/oas_client_gen.go | Regenerates client request encoding (expand param on GetGrant). |
| .changeset/grant-api-locators.md | Adds a minor changeset describing the API wire update and locator create support. |
Review details
- Files reviewed: 26/34 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Co-authored-by: Silvan <adlerhurst@users.noreply.github.com>
Grant expand inlines user attributes onto GrantUser, so ogen types them as GrantUserAttributes rather than UserAttributes. Compare the JSON in the expand integration test. Restore contains on user_id / team_id filters to match the previous principal_id filter. Co-authored-by: Silvan <adlerhurst@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
Identifier lookup is not scoped to the schemas designating each property and can grant access to the wrong user.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 26/34 changed files
- Comments generated: 4
- Review effort level: Balanced
Identifier lookup now ANDs schema_url to the schemas that set x-identifier for that key, so a unique undesignated property on another schema cannot be selected. Co-authored-by: Silvan <adlerhurst@users.noreply.github.com>
GrantUser.attributes is user content, not the schema document. Extra locator properties stay additionalProperties: false (req.invalid); XOR of id vs locator remains grant.invalid. Co-authored-by: Silvan <adlerhurst@users.noreply.github.com>
configureZitadel is write-once on globalThis, which survives Vitest isolate. A file that bound the DEV `/api` default first caused later specs to fetch localhost:3000 while their MSW handlers waited on http://localhost/api, so the first test in many files timed out in CI. Co-authored-by: Silvan <adlerhurst@users.noreply.github.com>
…en into cursor/grant-locators-54e2
…pers
The e2e grantProjectAdmin and dev-real grantDevUserAdmin helpers still sent
the old { principal_type, principal_id } body to POST /grants, which the new
locator contract rejects with grant.invalid ("exactly one of user or team is
required"). Send { user: { user_id }, relation } instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GVV2xsPNwqKzQY1xVZu73
IAM-marco
left a comment
There was a problem hiding this comment.
Approving. The two-axis review is fully addressed and CI is green.
- All prior review threads are resolved: the eight simplification points were fixed as suggested, and the one kept item (the handler-side exactly-one check) has a sound rationale (it checks field presence where the service checks trimmed values).
- The anti-enumeration follow-up landed: identifier create returns a success shape on a miss, ambiguity, or duplicate, and a session caller granting themselves is rejected. The residual body-shape leak is tracked in #1229.
- CI is green on this head. Getting there fixed two flaky infra issues and one real bug that the flakes had been masking: the Spanner-emulator lane timeout was raised (its slow authz list test runs in ~8m under the new budget), and two real-instance helpers that still sent the old grant body now send the user locator shape.
…or shape PR #1210 auto-generates `zitadel grants ...` CLI commands from the OpenAPI spec and shipped tests against the old grant shape. This PR (#1192) changed the grant contract to a user/team locator, so the two collided and cli:test failed. Reconcile the CLI to the new contract: - list filters: drop principal_type/principal_id (the server no longer accepts them) and send user_id/team_id instead. - columns/detail: read the nested response via dotted paths user.user_id and team.team_id (a grant is user XOR team). - create: the locator (user/team) is a nested object, so it goes through --data; only the scalar relation/expires_at derive flags. This is the CRUD framework's intended behavior, left unchanged. - tests: update the three failing unit files to the new schema and the framework's actual derived flags. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GVV2xsPNwqKzQY1xVZu73
## TL;DR Creating a grant by email address (`POST /grants` with `user.identifier`) now answers `202 Accepted` with no body, whatever happened: a match, a duplicate, an unknown address or an ambiguous one all get the same empty reply. Nothing to compare across repeats, no id to replay. Creating by `user_id` or team keeps `201` with the grant, whose `user` / `team` now carry only the id. Reads (`GET`/list) still return identifier and display. Granting yourself is rejected by user id as well as by identifier. ## Why Marco recorded the leak on issue #1229: PR #1192 made the HTTP status neutral, but the body still hydrated the user on a hit and not on a miss. The first commit here stripped the body to ids only. Copilot then showed that ids alone still leak: a repeat returned the same grant id on a hit and fresh random ids on a miss, and the returned user id could be replayed through the explicit path, where 409 and 404 tell the two apart. The second commit removes the body from the identifier path entirely. ## What changed - The identifier path returns a nil grant from the service and the handler answers with the generated empty `202`. The synthetic-grant and existing-grant helpers are deleted. - Creating by `user_id` or team never hydrates refs: one id-only builder produces every `201` body. The old `hydrateCreated` helper is gone. - Self-grant check also runs on the explicit `user_id` path. - OpenAPI: new `202` response on create, `201` scoped to the id and team locators, locator and error descriptions rewritten. Regenerated ogen files. Changeset for `@zitadel/server` (minor). - The Console discards the create reply and the CLI prints an empty result on 202, so neither needed code changes (typecheck and tests were run for both). <details> <summary>Not in this PR: the users-query allowlist cleanup</summary> The issue's third task, dropping `QueryUsers` from the session allowlist in `internal/api/security.go`, is left out on purpose. That map does not grant access. It marks operations that need a logged-in user rather than any session cookie, so removing the users query from it would let an anonymous cookie through, the opposite of the goal. The switch that decides whether a browser session may call the users query at all is the security block on `POST /users/query` in the OpenAPI, where the session cookie is listed as an accepted credential. Flipping that switch is not right either. The add-admin picker is not the only caller: the Console users page lists users through the same session call, and the CLI calls it too (with a project secret, so unaffected). The users query already checks whether the caller may list users of the session's project. What is missing is a permission that only admins hold, and that is #1024's job. Until it lands, anyone with any access to the Console project sees the list, a limitation the issue already records. Rate limiting the identifier path against volume and timing probes is its own follow-up ticket, not part of this change. </details> ## Tests Written first, per TDD: - Integration tests (postgres lane) covering bare create bodies on the user_id, team, and identifier paths. - A new key-set test that proves a hit response and a miss response have identical JSON shape (same top-level keys, same nested user keys). - A new test asserting your own `user_id` is rejected the same way your own identifier already was. - Service unit tests updated to match the id-only builder; the old hydrate-fallback test deleted since there's no hydrate path left to fall back from. - Unit tests, `go vet`, OpenAPI lint, and check-generate all green. Closes #1229
Summary
Breaking wire update of the grants HTTP API (alpha). Create, get, and list name the bound person as
user(user_id) orteam(team_id) instead ofprincipal_type/principal_id. Create accepts an id or a unique locator (user.identifier/team.name) and resolves it in the platform project. Storage (authz_assignments) andauthz.granted/authz.revokedevents stay(principal_type, principal_id).POST /grantswithuser.identifieris accepted with 201 whether or not a user matched, so the HTTP status cannot enumerate whether an address belongs to an active platform user. A miss or several matches still return a Grant and write nothing. A duplicate returns the existing row. Granting the session caller's own resolved user isgrant.invalid.user_idand team locators keep 404 / 409.expand: ["principal"]still exists as one flag for both kinds. Extra fields are copied onto the sameuser/teamobject (no siblingprincipal). Missing principals stay a degraded ref. GET-by-id takes the same expand query param and the same dualuser.read+team.readgate.Console add-admin stays picker-by-id; the request body is the mechanical
{ user: { user_id }, relation: "admin" }compile fix.Validation
Console Settings → Admins against
moon run console:dev-real(dev@zitadel.local): create a grant forada.lovelace@example.com(POST /grantswith{ user: { user_id }, relation: "admin" }), the list shows the row at level Admin, then remove viaDELETE /grants/{id}and the table returns to “No admins yet.”console_admins_create_list_delete_grant.mp4
go test ./internal/service -count=1 -run TestGrantService_Create— pass (identifier miss/ambiguous/duplicate → 201; self-grant → grant.invalid; user_id/team unchanged)go test ./internal/api -count=1 -run 'TestCreateGrant|TestGrantCaller|TestGrantResponse'— passmoon run server:generateandmoon run server:check-generate— passci / full-prfailed onTestGrantCreateLocators/unknown_identifier_is_accepted_without_a_rowracing sibling parallel creates; isolated that subtest onto its own project. Docker is unavailable here so the Spanner lane was not re-run locally.Release notes / changeset
Changeset:
.changeset/grant-api-locators.md—@zitadel/serverminor. Callers can create grants by user identifier or team name; create/get/list dropprincipal_type/principal_id. Creating by identifier always returns 201 except self-grant; it does not reveal whether the address matched.No additional changeset for the console test isolation fix — no shipped behavior changed.
Notes
oneOfwithout a discriminator made ogen skip grant operations, so create/get/list stay a single object and XOR is enforced in the handler/service asgrant.invalid.asgn_id still 404s. Volume/timing probes and rate limits stay on Neutral responses for grant creation by identifier #1229.To show artifacts inline, enable in settings.